NexusPi Git Node
Commit 2230d76a6c2f8f4d9117534fe39068d0ae8a5e05
Parents : 2bb269e
Author : Ivan <e318cbc04468bd574db2b4523dddd710>
Signature : T66BB85Valid, signed by author
Date : 2026-08-15T16:57:24-05:00
feat: implement fixed lxmf delivery hash handling in message sending and pathfinding logic
Changes
6 files changed, 381 insertions(+), 15 deletions(-)
Diff
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 48f86b8f..3b525860 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index b5af67fb..5d1a122e 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -9432,6 +9432,21 @@ class ReticulumMeshChat:
msg = "Propagated delivery is not available for messages to yourself."
raise ValueError(msg)
+ destination_identity = self.recall_identity(destination_hash)
+ if destination_identity is None and is_local_self and ctx.identity:
+ destination_identity = ctx.identity
+ if destination_identity is None:
+ msg = (
+ "Could not recall destination identity. "
+ "Wait for an announce from this peer, then try again."
+ )
+ raise LookupError(msg)
+
+ delivery_hash_bytes = reticulum_pathfinding.lxmf_delivery_hash_bytes(
+ destination_identity,
+ destination_hash_bytes,
+ )
+
# Direct/opportunistic need a live peer path. Propagated uses the
# propagation node, so skip the peer path wait (still record outcome).
if is_local_self:
@@ -9449,17 +9464,8 @@ class ReticulumMeshChat:
else:
# Reticulum keeps a live path table, and entries expire when peers move or links drop.
# We cannot replay "old" paths from the app layer. Transport.request_path refreshes discovery.
- path_outcome = await self._await_transport_path(destination_hash_bytes)
-
- destination_identity = self.recall_identity(destination_hash)
- if destination_identity is None and is_local_self and ctx.identity:
- destination_identity = ctx.identity
- if destination_identity is None:
- msg = (
- "Could not recall destination identity. "
- "Wait for an announce from this peer, then try again."
- )
- raise LookupError(msg)
+ # Wait on lxmf.delivery, not an identity hash or some other aspect dest.
+ path_outcome = await self._await_transport_path(delivery_hash_bytes)
# Direct/opportunistic delivery needs a live transport path. Propagated
# delivery can proceed without a peer path (it uses the propagation node).
@@ -9497,8 +9503,8 @@ class ReticulumMeshChat:
# send messages over a direct link by default
desired_delivery_method = LXMF.LXMessage.DIRECT
if (
- not ctx.message_router.delivery_link_available(destination_hash_bytes)
- and RNS.Identity.current_ratchet_id(destination_hash_bytes) is not None
+ not ctx.message_router.delivery_link_available(delivery_hash_bytes)
+ and RNS.Identity.current_ratchet_id(delivery_hash_bytes) is not None
):
# since there's no link established to the destination, it's faster to send opportunistically
# this is because it takes several packets to establish a link, and then we still have to send the message over it
@@ -9688,7 +9694,7 @@ class ReticulumMeshChat:
path_finding_measure=reticulum_pathfinding.format_outbound_path_finding_measure(
path_outcome,
),
- path_row_hash_hex=destination_hash.lower()
+ path_row_hash_hex=delivery_hash_bytes.hex()
if path_outcome.path_available
else None,
)
diff --git a/meshchatx/src/backend/reticulum_pathfinding.py b/meshchatx/src/backend/reticulum_pathfinding.py
index 96faad86..af058abd 100644
--- a/meshchatx/src/backend/reticulum_pathfinding.py
+++ b/meshchatx/src/backend/reticulum_pathfinding.py
@@ -208,6 +208,24 @@ def path_metadata_for_api(destination_hash: bytes) -> dict[str, bool]:
}
+def lxmf_delivery_hash_bytes(identity, fallback_hash: bytes) -> bytes:
+ """Return the lxmf.delivery destination hash for identity.
+
+ Identity hashes and other-aspect destination hashes are not the
+ LXMF mail address. Outbound path waits must use this dest, not the
+ raw hash the UI pasted.
+ """
+ if identity is None:
+ return fallback_hash
+ try:
+ computed = RNS.Destination.hash(identity, "lxmf", "delivery")
+ except Exception:
+ return fallback_hash
+ if isinstance(computed, (bytes, bytearray)) and len(computed) == 16:
+ return bytes(computed)
+ return fallback_hash
+
+
def prepare_fresh_path_request(
reticulum: Optional["ReticulumLike"],
destination_hash: bytes,
diff --git a/tests/backend/test_lxmf_live_tcp_path.py b/tests/backend/test_lxmf_live_tcp_path.py
new file mode 100644
index 00000000..cc01b3e3
--- /dev/null
+++ b/tests/backend/test_lxmf_live_tcp_path.py
@@ -0,0 +1,284 @@
+# SPDX-License-Identifier: 0BSD
+"""Live two-peer LXMF path finding over public TCP client interfaces.
+
+Picks a reachable TCP node from directory.rns.recipes, falling back to the
+home-config US-East host if the directory is empty. Two isolated RNS
+clients announce lxmf.delivery and request a path through that node.
+
+Enable with MESHCHAT_LIVE_RETICULUM=1 or MESHCHAT_LIVE_VALIDATION=1.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import random
+import socket
+import subprocess
+import sys
+import textwrap
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+import pytest
+
+from tests.backend.support.test_temp_dir import subprocess_test_env
+
+_RUN = (
+ os.environ.get("MESHCHAT_LIVE_RETICULUM") == "1"
+ or os.environ.get(
+ "MESHCHAT_LIVE_VALIDATION",
+ )
+ == "1"
+)
+
+DIRECTORY_URL = "https://directory.rns.recipes/api/directory/submitted?status=online"
+HOME_US_EAST = ("45.77.109.86", 4965, "RNS_Transport_US-East")
+PATH_TIMEOUT_S = int(os.environ.get("MESHCHAT_LIVE_TCP_PATH_TIMEOUT", "70"))
+CONNECT_TRIES = int(os.environ.get("MESHCHAT_LIVE_TCP_TRIES", "3"))
+
+
+def _tcp_reachable(host: str, port: int, timeout: float = 4.0) -> bool:
+ try:
+ with socket.create_connection((host, int(port)), timeout=timeout):
+ return True
+ except OSError:
+ return False
+
+
+def _fetch_recipe_tcp_nodes() -> list[tuple[str, int, str]]:
+ with urllib.request.urlopen(DIRECTORY_URL, timeout=20) as resp:
+ payload = json.loads(resp.read().decode("utf-8"))
+ rows = payload.get("data", payload) if isinstance(payload, dict) else payload
+ if not isinstance(rows, list):
+ return []
+ nodes = []
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ if str(row.get("status", "")).lower() != "online":
+ continue
+ if str(row.get("type", "")).lower() != "tcp":
+ continue
+ host = row.get("host")
+ port = row.get("port")
+ if not host or not port:
+ continue
+ host = str(host)
+ if host.count(":") > 1 and not host.startswith("["):
+ continue
+ name = str(row.get("name") or host)
+ nodes.append((host, int(port), name))
+ return nodes
+
+
+def _pick_candidates() -> list[tuple[str, int, str]]:
+ chosen: list[tuple[str, int, str]] = []
+ try:
+ recipes = _fetch_recipe_tcp_nodes()
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
+ recipes = []
+ random.shuffle(recipes)
+ for host, port, name in recipes:
+ if _tcp_reachable(host, port):
+ chosen.append((host, port, name))
+ if len(chosen) >= CONNECT_TRIES:
+ break
+ if HOME_US_EAST not in chosen and _tcp_reachable(HOME_US_EAST[0], HOME_US_EAST[1]):
+ chosen.append(HOME_US_EAST)
+ return chosen
+
+
+def _write_client_config(config_dir: Path, host: str, port: int) -> None:
+ config_dir.mkdir(parents=True, exist_ok=True)
+ (config_dir / "config").write_text(
+ "[reticulum]\n"
+ "enable_transport = No\n"
+ "share_instance = No\n"
+ "panic_on_interface_error = No\n"
+ "\n"
+ "[logging]\n"
+ "loglevel = 3\n"
+ "\n"
+ "[interfaces]\n"
+ " [[LiveTCP]]\n"
+ " type = TCPClientInterface\n"
+ " enabled = Yes\n"
+ f" target_host = {host}\n"
+ f" target_port = {port}\n",
+ encoding="utf-8",
+ )
+
+
+_PONG_SCRIPT = textwrap.dedent(
+ """\
+ import json, os, sys, time
+ import RNS
+
+ config_dir, share_dir, timeout_s = sys.argv[1], sys.argv[2], float(sys.argv[3])
+ stop_path = os.path.join(share_dir, "stop")
+ ready_path = os.path.join(share_dir, "pong.json")
+ RNS.Reticulum(configdir=config_dir, loglevel=RNS.LOG_ERROR)
+ identity = RNS.Identity()
+ dest = RNS.Destination(
+ identity, RNS.Destination.IN, RNS.Destination.SINGLE, "lxmf", "delivery",
+ )
+ with open(ready_path, "w", encoding="utf-8") as handle:
+ json.dump({"dest": dest.hash.hex(), "pub": identity.get_public_key().hex()}, handle)
+ deadline = time.time() + timeout_s + 20
+ while time.time() < deadline and not os.path.isfile(stop_path):
+ dest.announce()
+ time.sleep(4)
+ RNS.exit(0)
+ """
+)
+
+_PING_SCRIPT = textwrap.dedent(
+ """\
+ import json, os, sys, time
+ import RNS
+
+ config_dir, share_dir, timeout_s = sys.argv[1], sys.argv[2], float(sys.argv[3])
+ ready_path = os.path.join(share_dir, "pong.json")
+ result_path = os.path.join(share_dir, "ping.json")
+ RNS.Reticulum(configdir=config_dir, loglevel=RNS.LOG_ERROR)
+ identity = RNS.Identity()
+ local = RNS.Destination(
+ identity, RNS.Destination.IN, RNS.Destination.SINGLE, "lxmf", "delivery",
+ )
+ deadline = time.time() + 30
+ pong = None
+ while time.time() < deadline:
+ if os.path.isfile(ready_path):
+ with open(ready_path, encoding="utf-8") as handle:
+ pong = json.load(handle)
+ if pong.get("dest"):
+ break
+ time.sleep(0.2)
+ if not pong:
+ with open(result_path, "w", encoding="utf-8") as handle:
+ json.dump({"ok": False, "reason": "no_pong_hash"}, handle)
+ RNS.exit(0)
+ raise SystemExit(0)
+ peer = bytes.fromhex(pong["dest"])
+ RNS.Identity.remember(
+ RNS.Identity.full_hash(peer),
+ peer,
+ bytes.fromhex(pong["pub"]),
+ )
+ local.announce()
+ path_deadline = time.time() + timeout_s
+ while time.time() < path_deadline:
+ if RNS.Transport.has_path(peer) and RNS.Identity.recall(peer):
+ break
+ RNS.Transport.request_path(peer)
+ local.announce()
+ time.sleep(0.5)
+ identity_hash = identity.hash
+ delivery_hash = local.hash
+ with open(result_path, "w", encoding="utf-8") as handle:
+ json.dump(
+ {
+ "ok": bool(RNS.Transport.has_path(peer) and RNS.Identity.recall(peer)),
+ "has_path": bool(RNS.Transport.has_path(peer)),
+ "recalled": RNS.Identity.recall(peer) is not None,
+ "identity_is_delivery": identity_hash == delivery_hash,
+ "peer": pong["dest"],
+ },
+ handle,
+ )
+ RNS.exit(0)
+ """
+)
+
+
+def _run_pair(tmp_path: Path, host: str, port: int, name: str) -> dict:
+ ping_dir = tmp_path / f"ping_{port}"
+ pong_dir = tmp_path / f"pong_{port}"
+ share_dir = tmp_path / f"share_{port}"
+ share_dir.mkdir(parents=True, exist_ok=True)
+ _write_client_config(ping_dir, host, port)
+ _write_client_config(pong_dir, host, port)
+ env = subprocess_test_env()
+ pong = subprocess.Popen(
+ [
+ sys.executable,
+ "-c",
+ _PONG_SCRIPT,
+ str(pong_dir),
+ str(share_dir),
+ str(PATH_TIMEOUT_S),
+ ],
+ env=env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ ping = subprocess.Popen(
+ [
+ sys.executable,
+ "-c",
+ _PING_SCRIPT,
+ str(ping_dir),
+ str(share_dir),
+ str(PATH_TIMEOUT_S),
+ ],
+ env=env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ try:
+ ping.wait(timeout=PATH_TIMEOUT_S + 45)
+ except subprocess.TimeoutExpired:
+ ping.kill()
+ (share_dir / "stop").write_text("1", encoding="utf-8")
+ try:
+ pong.wait(timeout=15)
+ except subprocess.TimeoutExpired:
+ pong.kill()
+ result_path = share_dir / "ping.json"
+ payload = {
+ "ok": False,
+ "reason": "no_result_file",
+ "node": name,
+ "host": host,
+ "port": port,
+ }
+ if result_path.is_file():
+ payload = json.loads(result_path.read_text(encoding="utf-8"))
+ payload["node"] = name
+ payload["host"] = host
+ payload["port"] = port
+ payload["ping_return"] = ping.returncode
+ payload["pong_return"] = pong.returncode
+ if ping.returncode not in (0, None):
+ payload["ping_stderr"] = (ping.stderr.read() if ping.stderr else "")[-800:]
+ return payload
+
+
+@pytest.mark.integration
+@pytest.mark.skipif(
+ not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1 for live TCP path test"
+)
+def test_live_two_peer_path_over_random_tcp(tmp_path):
+ candidates = _pick_candidates()
+ if not candidates:
+ pytest.skip("no reachable public TCP nodes")
+
+ last = None
+ for host, port, name in candidates:
+ last = _run_pair(tmp_path, host, port, name)
+ print(f"live tcp path {name} {host}:{port} -> {last}", flush=True)
+ if last.get("ok") is True:
+ assert last.get("identity_is_delivery") is False
+ print(
+ f"LXMF_LIVE_TCP_PATH_PROVED {name} {host}:{port}",
+ flush=True,
+ )
+ return
+
+ pytest.fail(
+ f"no path between two new LXMF dests after {len(candidates)} TCP nodes: {last}"
+ )
diff --git a/tests/backend/test_message_sending_failures.py b/tests/backend/test_message_sending_failures.py
index d21843c9..2f8d54bd 100644
--- a/tests/backend/test_message_sending_failures.py
+++ b/tests/backend/test_message_sending_failures.py
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
import LXMF
import pytest
+import RNS
from meshchatx.meshchat import ReticulumMeshChat
from meshchatx.src.backend.reticulum_pathfinding import OutboundPathOutcome
@@ -43,7 +44,48 @@ def mock_app():
@pytest.mark.asyncio
-async def test_send_message_no_path_identity_recall_fails(mock_app):
+async def test_oracle_path_wait_uses_lxmf_delivery_hash_not_identity_hash(mock_app):
+ """Pasting an identity hash must wait on lxmf.delivery, not the identity hash."""
+ ident = RNS.Identity()
+ identity_hex = ident.hash.hex()
+ delivery = RNS.Destination.hash(ident, "lxmf", "delivery")
+ assert ident.hash != delivery
+ mock_app.recall_identity = MagicMock(return_value=ident)
+ mock_app._await_transport_path = AsyncMock(
+ return_value=OutboundPathOutcome(False, "new_path_requested", True),
+ )
+ mock_app._is_self_lxmf_destination = MagicMock(return_value=False)
+ with pytest.raises(TimeoutError, match="No path to destination"):
+ await mock_app.send_message(
+ destination_hash=identity_hex,
+ content="hi",
+ delivery_method="direct",
+ )
+ mock_app._await_transport_path.assert_awaited_once_with(delivery)
+ mock_app.message_router.handle_outbound.assert_not_called()
+ print("LXMF_IDENTITY_HASH_PATH_WAIT_ORACLE_PROVED")
+
+
+@pytest.mark.asyncio
+async def test_oracle_path_wait_keeps_lxmf_delivery_hash(mock_app):
+ ident = RNS.Identity()
+ delivery = RNS.Destination.hash(ident, "lxmf", "delivery")
+ mock_app.recall_identity = MagicMock(return_value=ident)
+ mock_app._await_transport_path = AsyncMock(
+ return_value=OutboundPathOutcome(False, "new_path_requested", True),
+ )
+ mock_app._is_self_lxmf_destination = MagicMock(return_value=False)
+ with pytest.raises(TimeoutError, match="No path to destination"):
+ await mock_app.send_message(
+ destination_hash=delivery.hex(),
+ content="hi",
+ delivery_method="direct",
+ )
+ mock_app._await_transport_path.assert_awaited_once_with(delivery)
+
+
+@pytest.mark.asyncio
+async def test_send_message_recall_fails_before_path_wait(mock_app):
destination_hash = "aa" * 16
mock_app.recall_identity = MagicMock(return_value=None)
with pytest.raises(LookupError, match="Could not recall destination identity"):
@@ -51,6 +93,7 @@ async def test_send_message_no_path_identity_recall_fails(mock_app):
destination_hash=destination_hash,
content="hi",
)
+ mock_app._await_transport_path.assert_not_awaited()
@pytest.mark.asyncio
diff --git a/tests/backend/test_reticulum_pathfinding.py b/tests/backend/test_reticulum_pathfinding.py
index feaf1105..2b13bef0 100644
--- a/tests/backend/test_reticulum_pathfinding.py
+++ b/tests/backend/test_reticulum_pathfinding.py
@@ -220,6 +220,21 @@ def test_prepare_fresh_uses_expire_path_without_reticulum():
req.assert_called_once_with(DEST)
+def test_lxmf_delivery_hash_bytes_differs_from_identity_hash():
+ ident = RNS.Identity()
+ identity_hash = ident.hash
+ delivery = RNS.Destination.hash(ident, "lxmf", "delivery")
+ assert identity_hash != delivery
+ assert rp.lxmf_delivery_hash_bytes(ident, identity_hash) == delivery
+ assert rp.lxmf_delivery_hash_bytes(ident, delivery) == delivery
+
+
+def test_lxmf_delivery_hash_bytes_falls_back_when_identity_unusable():
+ fallback = bytes(range(16))
+ assert rp.lxmf_delivery_hash_bytes(object(), fallback) == fallback
+ assert rp.lxmf_delivery_hash_bytes(None, fallback) == fallback
+
+
def test_lxmf_path_wait_cap_uses_rns_default_without_destination():
v = rp.lxmf_path_wait_cap_seconds()
assert 30.0 <= v <= 120.0
Served by rngit 1.5.2 - Generated in 0.05s